Hyunjung Im

Frontend Developer

github

Unit testing

2023-02-27

unit-testing

Unit Testing 책을 읽고 정리한 글입니다.

저는 부트캠프 백엔드 기간이 거의 끝나갈 때쯤부터 테스트 코드를 작성하기 시작했어요. 미루고 미루다 이제는 정말로 작성을 해봐야겠다는 생각으로 시도했었는데 생각보다 쉽지 않았어요. 목킹은 왜 하는지.. 단계를 이해하는 것에만 많은 시간이 걸렸던 것 같아요.

제가 테스트하고 있는 방법이 맞는지 확신이 들지 않아서 이 책을 읽어보게 됐어요. 내용이 좋은 것 같아 공유해봅니다. 책의 예제는 C#을 다루고 있어 백엔드를 하시는 분들에게 좀 더 도움이 될 것 같은 책이었지만 전체적인 큰 틀에서 배울 수 있는 부분이 많았어요.

테스트를 많이 작성해보지 않으셨다면 제가 정리한 글은 간단하게 훑어만 보시고 아주 간단한 것부터 한 번 작성해보세요! 테스트가 익숙하지 않을 때보다 조금은 익숙해지고 나면 밑의 내용은 금방 흡수하실 수 있을 거에요.


목차


왜 테스트 코드를 작성해야 할까요?

테스트 코드를 작성함으로써 얻을 수 있는 이점은 다음과 같습니다.

  • 코드를 검증할 수 있다.
  • 코드를 더 나은 설계로 이끌 수 있다.
  • 소프트웨어 프로젝트의 지속 가능한 성장을 가능하게 한다.

단위 테스트의 핵심 은 프로젝트가 쉽게 성장할 수 있게 만든다는 점입니다. 코드베이스에서 무언가를 변경할 때마다 무질서도(엔트로피)는 증가합니다. 지속적인 정리와 리팩터링 등과 같은 적절한 관리를 하지 않고 방치하면 시스템이 점점 더 복잡해지고 무질서해집니다. 도미노 현상처럼 소프트웨어의 한 부분을 수정하면 다른 부분들이 고장 나게 되고 결국엔 코드베이스를 신뢰할 수 없게 됩니다.

테스트로 이러한 점을 예방할 수 있습니다. 테스트는 안정망 역할을 하고, 대부분의 회귀에 대한 보험을 제공하는 도구라고 할 수 있어요. 테스트는 새로운 기능을 도입하거나 새로운 요구 사항에 더 잘 맞게 리팩터링한 후에도 기존 기능이 잘 작동하는지 확인하는 데 도움이 됩니다.

그렇다면, 테스트 코드가 없다면 좋은 설계를 가진 코드를 작성할 수 없을까요?

  • 아니요. 무조건 적으로 테스트 코드가 없는 프로젝트를 나쁜 설계를 가진 프로젝트라 말할 수는 없습니다. 테스트 코드가 꼭 좋은 설계를 만든다고 보장할 수는 없으니까요. 그리고 환경적인 요인도 고려해야 합니다. 테스트 코드도 결국 유지 보수해야 할 코드 중 하나이기 때문에 유지 보수할 인력이 부족하다면 테스트 코드를 작성하기 쉽지 않을 수 있습니다.

테스트 코드의 단점

  • 테스트 코드를 작성하기 위한 노력이 필요하다.
  • 잘못 작성된 테스트 코드는 앞서 말한 이점을 누리기 힘들다.
  • 테스트 코드도 결국 유지 보수해야 할 코드이다.
test-01

일부 테스트는 소프트웨어 품질에 많은 기여를 합니다. 그 밖에 다른 테스트는 그렇지 않을 수도 있습니다. 잘못된 경고가 발생하고, 회귀 오류를 알아내는 데 도움이 되지 않으며, 유지 보수를 어렵게 합니다. 프로젝트에 도움이 되는지 여부를 명확하게 파악하지 않고 단위 테스트를 작성하는 데만 빠져들기 쉬울 수 있습니다.


커버리지 수치가 높을수록 좋은 테스트 코드일까요?

커버리지는 테스트 수트가 소스 코드를 얼마나 실행하는지를 백분율로 나타내는 수치에요. 각기 다른 유형이 있고, 테스트 품질을 평가하는 데 자주 사용되고 일반적으로는 커버리지 숫자가 높을수록 더 좋습니다.

하지만 테스트 품질을 결정하는 데는 어떤 커버리지 지표도 의존할 수는 없습니다. 같은 항목의 테스트라고 해도, 어떻게 작성하느냐에 따라 테스트 커버리지가 달라질 수 있어요. 이처럼 커버리지의 수치는 신뢰할 수 있는 수치가 아닐 수 있습니다.

  • 커버리지가 낮다는 것은 문제의 징후이지만, 커버리지가 높다고 해서 테스트 스위트의 품질이 높은 것은 아니다.
  • 테스트 대상 시스템의 모든 가능한 결과를 검증한다고 보장할 수 없다.
  • 외부 라이브러리의 코드 경로를 고려할 수 있는 커버리지 지표는 없다.

하지만 제 개인적인 생각으로 커버리지 수치는 테스트의 목표가 없을 땐 꽤 도움이 된다고 생각합니다. 여기서 중요한 점은 커버리지 수치를 목표로 삼되, 모든 코드를 테스트하는 게 아니라 중요한 부분만을 테스트 해야 한다는 점이에요.

이전 프로젝트 때 커버리지 100%를 목표로 진행을 했었는데 100%는 달성하지 못하고 93.78%를 기록했어요. 이 책을 읽고 나니 커버리지 100%라는 목표가 얼마나 바보 같았는지 알게 됐네요🤪(책에서는 커버리지 100%는 거의 불가능하다고 나옵니다.)
커버리지 수치만 생각하고 테스트를 작성하다 보니 몇몇 컴포넌트에서 테스트를 작성하기 힘든 부분이 있었는데, 그 부분이 중요한 역할을 하지 않음에도 계속 시간을 끌며 진행했던 적이 많았어요. 단위 테스트에서 커버할 수 없는 영역이었던 부분도 많았던 것 같아요. 커버리지에만 집중하다 보니 코드 개선의 목적이 아닌, 커버리지만을 위한 테스트 코드를 작성하게 된 거죠.

coverage 제가 이 프로젝트에서 어떤 부분을 리팩토링 한다면 몇 개의 테스트 코드를 리팩토링 해야 할지.. 기대되네요..🥹

단위 테스트 구조

단위 테스트는 다음 세 가지 요구 사항을 충족하는 테스트입니다.

  • 단일 동작 단위를 검증하고,
  • 빠르게 수행하고,
  • 다른 테스트와 별도로 처리한다.

이 세 가지 요구 사항 중 하나라도 충족하지 못하는 테스트는 통합 테스트 범주에 속합니다.


AAA 패턴 사용

AAA 패턴은 각 테스트를 준비, 실행, 검증이라는 세 부분으로 나눌 수 있습니다. AAA 패턴은 테스트 스위트 내 모든 테스트가 단순하고 균일한 구조를 갖는 데 도움이 됩니다. 이러한 일관성이 이 패턴의 가장 큰 장점 중 하나입니다.

준비 구절 테스트 대상 시스템과 해당 의존성을 원하는 상태로 만듭니다.
실행 구절 테스트 대상 시스템에서 메서드를 호출하고 준비된 의존성을 전달하며 출력 값을 캡처합니다.
검증 구절 결과를 검증합니다.

Given-When-Then 패턴

이 패턴도 테스트를 세 부분으로 나눕니다.

Given 준비 구절에 해당
When 실행 구절에 해당
Then 검증 구절에 해당

테스트 구성 측면에서 두 패턴 사이에 차이는 없습니다. 유일한 차이점은 프로그래머가 아닌 사람에게 Given-When-Then 구조가 더 읽기 쉽다는 것입니다. Given-When-Then은 비기술자들과 공유하는 테스트에 더 적합합니다.


테스트를 작성할 때는 준비 구절부터 시작하는 것이 자연스럽습니다. 그다음 다른 두 구절을 작성합니다. 이 방법은 대부분의 경우에 효과적이지만, 검증 구절로 시작하는 것도 가능한 옵션입니다. 테스트 주도 개발(TDD) 을 실천할 때, 즉 기능을 개발하기 전에 실패할 테스트를 만들 때는 아직 기능이 어떻게 동작할지 충분히 알지 못합니다. 따라서 먼저 기대하는 동작으로 윤곽을 잡은 다음, 이러한 기대에 부응하기 위한 시스템을 어떻게 개발할지 아는 것이 좋습니다.


여러 개의 준비, 실행, 검증 구절 피하기

여러 개의 준비, 실행, 검증 구절은 테스트가 너무 많은 것을 한 번에 검증한다는 의미입니다. 이러한 테스트는 여러 테스트로 나눠서 해결합니다. (통합 테스트에서는 실행 구절을 여러 개 두는 것이 괜찮을 때도 있습니다.)


테스트 내 if 문 피하기

if문은 테스트가 한 번에 너무 많은 것을 검증한다는 표시이므로 안티 패턴입니다. 테스트에 분기가 있어서 얻는 이점은 없습니다. if문은 테스트를 읽고 이해하는 것을 더 어렵게 만듭니다.



좋은 단위 테스트의 4대 요소

좋은 단위 테스트에는 다음 네 가지 특성이 있습니다. 이 특성은 단위 테스트, 통합 테스트, 엔드 투 엔드 테스트 등 자동화된 테스트를 분석하는 데 사용할 수 있습니다.

  • 회귀 방지(소프트 웨어 버그)
  • 리팩터링 내성
  • 빠른 피드백
  • 유지 보수성

이 네 가지 특성을 곱하면 테스트의 가치가 결정됩니다. 여기서 곱셈은 수학적인 의미로, 어떤 특성이라도 0이 되면 전체가 0이 됩니다. 테스트 코드를 포함한 모든 코드는 책임입니다. 많은 양의 테스트 코드보다 적은 양이라도 매우 가치 있는 테스트가 프로젝트의 성장에 훨씬 더 효과적입니다.

좋은 테스트를 만드는 특성 간에 균형을 이뤄내기는 쉽지 않습니다. 처음 세 가지 범주에서 점수를 모두 최대로 낼 수 없고, 유지 보수 관점을 계속 지켜야 테스트가 짧아지고 간결해집니다. 따라서 부분적으로 그리고 전략적인 방법으로 부분적으로 희생하며 절충해야 합니다.


1. 회귀 방지

회귀는 소프트 웨어 버그를 말합니다. 코드를 수정한 후 (일반적으로 새 기능을 출시한 후) 기능이 의도한 대로 작동하지 않는 경우입니다. 코드베이스가 커질수록 잠재적인 버그에 더 많이 노출되기 때문에 회귀에 대해 효과적인 보호 역할이 필요합니다. 회귀 방지 지표에 대한 테스트 점수가 얼마나 잘 나오는지 평가하려면 다음 사항을 고려해야 합니다.

  • 테스트 중에 실행되는 코드의 양
  • 코드 복잡도
  • 코드의 도메인 유의성

일반적으로 실행되는 코드가 많을수록 테스트에서 회귀가 나타날 가능성이 높습니다. 코드가 예외를 발생시키지 않고 실행된다는 것을 아는 것이 도움은 되지만, 코드가 생성하는 결과가 유효한지도 확인해야 합니다.

코드의 양뿐만 아니라 복잡도와 도메인 유의성도 중요합니다. 비즈니스에 중요한 기능에서 발생한 버그가 가장 큰 피해를 주기 때문이죠. 반면에 단순한 코드를 테스트하는 것은 가치가 거의 없습니다. 단순한 코드를 다루는 테스트는 실수할 여지가 많지 않기 때문에 회귀 오류가 많이 생기지 않습니다.

그리고 작성한 코드 외의 외부 코드도 중요합니다. 이 코드들은 작성한 코드만큼이나 소프트웨어 작동에 영향을 미칩니다. 최상의 보호를 위해서는 테스트가 해당 라이브러리, 프레임워크, 외부 시스템을 테스트 범주에 포함해서 소프트웨어가 이러한 의존성에 대한 검증이 올바른지 확인해야 합니다.


2. 리팩터링 내성

이는 테스트를 “빨간색”(실패)로 바꾸지 않고 기본 애플리케이션 코드를 리팩터링할 수 있는지에 대한 척도입니다.
거짓 양성 이 적은 테스트 코드를 리팩터링 내성이 강하다고 말합니다.


거짓 양성이란 무엇일까요?

코드를 리팩터링 한 후에 기능은 예전과 같이 완벽하게 작동하지만, 테스트는 통과하지 않는 것을 말합니다.

단위 테스트의 목표는 앞서 말했듯이 프로젝트 성장을 지속 가능하게 하는 것입니다. 여기에는 두 가지 장점이 있는데 아래와 같습니다.

1 기존 기능이 고장 났을 때 테스트가 조기 경고를 제공한다.
2 코드 변경이 버그로 이어지지 않을 것이라고 확신하게 된다. 이러한 확신이 없으면 리팩터링을 하는 데 주저하게 되고 코드베이스가 나빠질 가능성이 높아진다.

거짓 양성은 이 두 가지 이점을 모두 방해합니다.

테스트가 타당한 이유 없이 실패하면, 실패에 익숙해지고 그만큼 신경을 많이 쓰지 않는다.
거짓 양성이 빈번하면 테스트 suite에 대한 신뢰가 서서히 떨어진다. 신뢰가 부족해지면 리팩터링이 줄어든다. 버그를 피하려고 코드 변경을 최소한으로 하기 때문이다.

거짓 양성 피하기

  • 테스트와 테스트 대상의 구현 세부 사항이 많이 결합할수록 허위 경보가 더 많이 생깁니다. 거짓 양성이 생길 가능성을 줄이는 방법은 해당 구현 세부 사항에서 테스트를 분리하는 것뿐입니다.

  • 테스트를 통해 테스트 대상이 제공하는 최종 결과를 검증하는지 확인해야 합니다. 테스트는 최종 사용자에게 의미 있는 결과만 확인하고 다른 모든 것은 무시해야 합니다.

나쁜 테스트
test-02
위와 같은 테스트는 특정 구현(결과 전달을 위해 테스트 대상이 수행해야 할 특정 단계)을 예상하므로 깨지기 쉽습니다. 테스트 대상을 리팩터링하면 모두 테스트 실패로 이어지게 됩니다.
좋은 테스트
test-03
구현 세부 사항이 아닌 테스트 대상의 식별할 수 있는 동작과 결합된 테스트. 이러한 테스트는 리팩터링 내성이 있고, 거짓 양성은 거의 발생하지 않습니다.

3. 빠른 피드백

빠른 피드백은 단위 테스트의 필수 속성입니다. 테스트 속도가 빠를수록 더 많은 테스트를 수행할 수 있고 더 자주 실행할 수 있습니다.


4. 유지 보수성

이 지표는 다음 두 가지 주요 요소로 구성됩니다.

  • 테스트가 얼마나 이해하기 어려운가?
    • 테스트는 코드 라인이 적을수록 더 읽기 쉽습니다. 물론 단지 라인 수를 줄이기 위해 인위적으로 압축하지 않는다고 가정할 때를 말합니다. 테스트를 작성할 때는 절차를 생략하지 않고 일급 시민(first-class citizen) 으로 취급하는 것이 좋습니다.
  • 테스트가 얼마나 실행하기 어려운가?

극단적인 사례. E2E 테스트

엔드 투 엔드 테스트는 많은 코드를 테스트하므로 회귀 방지(버그감지)를 훌륭히 해냅니다. 그리고 리팩터링 내성도 우수합니다. 하지만 이러한 이점에도 E2E 테스트에는 느린 속도라는 큰 단점이 있습니다.

회귀 방지, 리팩터링 내성의 기능은 우수하지만 빠른 피드백의 지표에서는 실패합니다. 그리고 E2E테스트는 관련된 모든 의존성을 설정해야 하므로 일반적으로 크기가 더 크고 계속 운영하기 위해서는 추가적인 노력이 더 들게 되어 유지비 측면에서 더 비싼 경향이 있습니다.



이상적인 테스트?

test-04

이 세 가지 특성 중 두 가지를 극대화하는 테스트를 만들기는 매우 쉽지만, 나머지 특성 한 가지를 희생해야만 가능합니다. 안타깝게도 세 가지 특성 모두 완벽한 점수를 얻어서 이상적인 테스트를 만드는 것은 불가능합니다.



그럼 어떤 부분에 집중해야 할까요?

test-05

리팩터링 내성 에 집중해야 합니다. E2E 테스트만 쓰거나 테스트가 상당히 빠르지 않는 한, 리팩터링 내성을 최대한 많이 갖는 것을 목표로 해야 합니다.

  • 리팩터링 내성을 포기할 수 없는 이유는 테스트가 이 특성을 갖고 있는지 여부는 대부분 이진 선택이기 때문입니다. 중간이 거의 없어 리팩터링 내성을 조금만 인정할 수는 없는 반면, 회귀 방지와 빠른 피드백에 대한 지표는 조절이 가능합니다.
test-07

테스트 피라미드는 종종 세 가지 유형의 피라미드로 표현합니다. 테스트 유형에 따라 빠른 피드백과 회귀 방지 사이에서 선택을 합니다. 피라미드 상단의 테스트는 회귀 방지에 유리한 반면, 하단은 실행 속도를 강조합니다.

test-06

어느 계층도 리팩터링 내성을 포기하지 않습니다. 테스트 유형 간의 정확한 비율은 각 팀과 프로젝트마다 다르지만, 일반적으로는 피라미드 형태를 유지해야 합니다.




목과 스텁

테스트 대역은 모든 유형의 비운영용 가짜 의존성을 설명하는 포괄적인 용어입니다. 이 용어는 영화 산업의 “스턴트 대역”이라는 개념에서 비롯됐어요. Gerard Meszaros에 따르면, 테스트 대역에는 더미, 스텁, 스파이, 목, 페이크 라는 다섯 가지가 있지만 실제로는 목과 스텁의 두 가지 유형으로 나눌 수 있습니다.


            테스트 대역
                |
    ------------------------
    |                      |
    목                     스텁
(목, 스파이)          (스텁, 더미, 페이크)
외부로 나가는 상호 작용을 모방하고 검사하는 데 도움이 된다.
스텁 내부로 들어오는 상호 작용을 모방하는 데 도움이 된다.
                    목
                    |
       - 이메일 발송 ---->  SMTP 서버
      |
테스트 대상 시스템
      |
       - 데이터 검색 ---->  데이터베이스
                    |
                   스텁
  • 이메일 발송은 외부로 나가는 상호 작용입니다. 목은 이러한 상호 작용을 모방하는 테스트 대역에 해당합니다.
  • 데이터베이스에서 데이터를 검색하는 것은 내부로 들어오는 상호 작용으로 사이드 이펙트를 일으키지 않습니다. 해당 테스트 대역은 스텁입니다.


통합 테스트를 하는 이유

단위 테스트에만 전적으로 의존하면 시스템이 전체적으로 잘 작동하는지 확신할 수 없습니다. 단위 테스트가 비즈니스 로직을 확인하는 데 좋지만, 비즈니스 로직을 외부와 단절된 상태로 확인하는 것만으로는 충분하지 않아요.

통합 테스트의 역할

통합 테스트는 대부분 시스템이 프로세스 외부 의존성과 통합해 어떻게 작동하는지를 검증합니다.

단위 테스트와 통합 테스트 간의 균형을 유지하는 것이 중요합니다. 통합 테스트가 프로세스 외부 의존성에 직접 작동하면 느려지며, 이러한 테스트는 유지비가 많이 듭니다.

반면 통합 테스트는 코드를 더 많이 거치므로 회귀 방지가 단위 테스트보다 우수합니다. 또한, 제품 코드와의 결합도가 낮아서 리팩터링 내성도 우수합니다. 책에서는 단위 테스트와 통합 테스트의 비율을 아래와 같이 설명합니다.

단위 테스트로 가능한 한 많이 비즈니스 시나리오의 예외 상황을 확인하고, 통합 테스트는 주요 흐름과 단위 테스트가 다루지 못하는 기타 예외 상황을 다룬다.



요약

  • 테스트의 핵심은 프로젝트가 쉽게 성장할 수 있게 도와줄 수 있고, 코드를 더 나은 설계로 이끌어줄 수 있다는 점이다.
  • 테스트의 단점으로는 작성해야 할 노력이 필요하고 유지 보수해야 할 코드가 늘어난다는 점이다. (테스트 코드도 유지보수해야 할 코드이다.)
  • 모든 테스트를 똑같이 작성할 필요는 없다. 각각의 테스트는 비용과 편익 요소가 있으며, 둘 다 신중하게 따져볼 필요가 있다. 애플리케이션과 테스트 코드는 모두 자산이 아니라 부채다.
  • 커버리지 지표는 좋은 부정 지표이지만 나쁜 긍정 지표다.
  • 좋은 테스트의 4대 요소로는 회귀 방지, 리팩터링 내성, 빠른 피드백, 유지 보수성이 있고 리팩터링 내성에 더욱 집중해야 한다.
  • 테스트에 시간을 투자할 때는 항상 최대한의 이득을 얻도록 노력해야 한다. 초반 테스트에 익숙해졌다면 그다음엔, 테스트에 드는 노력을 가능한 한 줄이고 그에 따르는 이득을 최대화해야 한다.